package com.onadake.domain.query;

import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;

import com.onadake.utils.DomainUtils;
import com.onadake.utils.ValidationUtils;

/**
 * Describes one criterium which is used for the filter.
 * This is the criterium only for one column. Template type is the type of the column.
 * Remember this is a criterium for business logic.
 * This doesn't necessary mean there always exists same column in the data source layer.
 * However every criterium must be well defined in the data source layer.
 * 
 * @author radek.hecl
 *
 * @param <ColumnType> type of the column used in criterium
 */
public class Criterium<ColumnType> {

    /**
     * Builder object.
     *
     * @param <ColumnType> type of the column used in the result criterium
     */
	public static class Builder<ColumnType> {

	    /**
	     * Column of the criterium.
	     */
		private ColumnType column;
		
		/**
		 * Criterium operator.
		 */
		private Operator operator;
		
		/**
		 * Criterium value.
		 */
		private Object value;
		
		/**
		 * Sets criterium column.
		 * 
		 * @param column column to use
		 * @return this instance
		 */
		public Builder<ColumnType> setColumn(ColumnType column) {
			this.column = column;
			return this;
		}
		
		/**
		 * Sets criterium operator.
		 * 
		 * @param operator operator to use
		 * @return this instance
		 */
		public Builder<ColumnType> setOperator(Operator operator) {
			this.operator = operator;
			return this;
		}
		
		/**
		 * Sets criterium value object.
		 * 
		 * @param value value object to use
		 * @return this instance
		 */
		public Builder<ColumnType> setValue(Object value) {
			this.value = value;
			return this;
		}
		
		/**
		 * Builds the result object.
		 * 
		 * @return created object
		 */
		public Criterium<ColumnType> build() {
			return new Criterium<ColumnType>(this);
		}
	}
	
    /**
     * Column of the criterium.
     */
    private ColumnType column;
    
    /**
     * Criterium operator.
     */
    private Operator operator;
    
    /**
     * Criterium value.
     */
    private Object value;
	
    /**
     * Creates new instance.
     * 
     * @param builder builder object
     */
	public Criterium(Builder<ColumnType> builder) {
		column = builder.column;
		operator = builder.operator;
		value = DomainUtils.copyObject(builder.value);
		guardInvariants();
	}
	
	/**
	 * Guards this object to be consistent. Throws exception if this is not the case.
	 */
	private void guardInvariants() {
		ValidationUtils.guardNotNull(column, "column cannot be null");
		ValidationUtils.guardNotNull(operator, "operator cannot be null");
		if (operator == Operator.IN) {
			ValidationUtils.guardInstanceOf(value, Collection.class, "value must be collection for IN operator");
		}
	}
	
	/**
	 * Returns the column of this criterium.
	 * 
	 * @return column of this criterium
	 */
	public ColumnType getColumn() {
		return column;
	}
	
	/**
	 * Returns operator of this criterium.
	 * 
	 * @return operator of this criterium
	 */
	public Operator getOperator() {
		return operator;
	}
	
	/**
	 * Returns value of this criterium.
	 * 
	 * @return value of this criterium
	 */
	public Object getValue() {
		return DomainUtils.copyObject(value);
	}
	
	/**
	 * Creates the criterium.
	 * 
	 * @param column column
	 * @param operator operator
	 * @param value value
	 * @return created criterium
	 */
	public static <T> Criterium<T> create(T column, Operator operator, Object value) {
		return new Criterium.Builder<T>().
				setColumn(column).
				setOperator(operator).
				setValue(value).
				build();
	}
	
	/**
	 * Creates the criterium with EQUAL operator.
	 * 
	 * @param column column
	 * @param value value
	 * @return created criterium
	 */
	public static <T> Criterium<T> createEqual(T column, Object value) {
		return new Criterium.Builder<T>().
				setColumn(column).
				setOperator(Operator.EQUAL).
				setValue(value).
				build();
	}

	/**
	 * Creates the criterium with NOT_EQUAL operator.
	 * 
	 * @param column column
	 * @param value value
	 * @return created criterium
	 */
	public static <T> Criterium<T> createNotEqual(T column, Object value) {
		return new Criterium.Builder<T>().
				setColumn(column).
				setOperator(Operator.NOT_EQUAL).
				setValue(value).
				build();
	}

	/**
     * Creates the criterium with LESS operator.
     * 
     * @param column column
     * @param value value
     * @return created criterium
     */
    public static <T> Criterium<T> createLess(T column, Object value) {
        return new Criterium.Builder<T>().
                setColumn(column).
                setOperator(Operator.LESS).
                setValue(value).
                build();
    }

    /**
     * Creates the criterium with LESS_OR_EQUAL operator.
     * 
     * @param column column
     * @param value value
     * @return created criterium
     */
    public static <T> Criterium<T> createLessOrEqual(T column, Object value) {
        return new Criterium.Builder<T>().
                setColumn(column).
                setOperator(Operator.LESS_OR_EQUAL).
                setValue(value).
                build();
    }

    /**
     * Creates the criterium with GREATER operator.
     * 
     * @param column column
     * @param value value
     * @return created criterium
     */
    public static <T> Criterium<T> createGreater(T column, Object value) {
        return new Criterium.Builder<T>().
                setColumn(column).
                setOperator(Operator.GREATER).
                setValue(value).
                build();
    }

    /**
     * Creates the criterium with GREATER_OR_EQUAL operator.
     * 
     * @param column column
     * @param value value
     * @return created criterium
     */
    public static <T> Criterium<T> createGreaterOrEqual(T column, Object value) {
        return new Criterium.Builder<T>().
                setColumn(column).
                setOperator(Operator.GREATER_OR_EQUAL).
                setValue(value).
                build();
    }
    
	/**
	 * Creates the criterium with IN operator.
	 * Values collection is automatically converted into the set.
	 * 
	 * @param column column
	 * @param values values
	 * @return created criterium
	 */
	public static <T> Criterium<T> createIn(T column, Collection<?> values) {
		return new Criterium.Builder<T>().
				setColumn(column).
				setOperator(Operator.IN).
				setValue(new HashSet<Object>(values)).
				build();
	}

	/**
	 * Creates the criterium with LIKE operator.
	 * 
	 * @param column column
	 * @param value value
	 * @return created criterium
	 */
	public static <T> Criterium<T> createLike(T column, Object value) {
		return new Criterium.Builder<T>().
				setColumn(column).
				setOperator(Operator.LIKE).
				setValue(value).
				build();
	}
	
	/**
	 * Converts set of criteria to the map.
	 * Keys in the result map will be "numCriteria", "criterium[x].column", "criterium[x].operator" and "criterium[x].value",
	 * where x is index of the criterium.
	 * In addition it is possible to make a string prefix for the keys.
     * For example if prefix is "data", then keys will be "data.numCriteria", "data.criterium[x]...".
	 * 
	 * @param criteria criteria which will be converted
	 * @param prefix prefix which should be used
	 * @param converter criterium converter 
	 * @return map with converted criteria
	 */
	public static <T> Map<String, String> criteria2Map(Set<Criterium<T>> criteria, String prefix,
	        CriteriumStringMapConverter<T> converter) {
        if (StringUtils.isEmpty(prefix)) {
            prefix = "";
        }
        else {
            prefix = prefix + ".";
        }
        Map<String, String> res = new HashMap<>();
        res.put(prefix + "numCriteria", String.valueOf(criteria.size()));
        int i = 0;
        for (Criterium<T> criterium : criteria) {
            Map<String, String> crMap = converter.encode(criterium);
            String crPrefix = prefix + "criterium[" + i + "].";
            for (String crKeyStr : crMap.keySet()) {
                ValidationUtils.guardMatch(crKeyStr, "^(column|operator|value.*)$",
                        "converted criterium map keys must match ^(column|operator|value.*)$: convertedMap = " + crMap);
                res.put(crPrefix + crKeyStr, crMap.get(crKeyStr));
            }
            ++i;
        }
        return res;
	}

    /**
     * Returns whether criteria exists within the specified parameters.
     * This method looks to the key "numCriteria" and if this key is defined, then returns true. Otherwise false.
     * It is possible to make a string prefix for the key.
     * For example if prefix is "data", then tested keys will be "data.numCriteria".
     * 
     * @param map parameters map
     * @param prefix prefix, allowed empty or null
     * @return whether criteria exists in the specified parameters map or not
     */
    public static boolean isCriteriaExists(Map<String, String> map, String prefix) {
        if (StringUtils.isEmpty(prefix)) {
            prefix = "";
        }
        else {
            prefix = prefix + ".";
        }
        return map.containsKey(prefix + "numCriteria");
    }
    
    /**
     * Converts map to the set of criteria.
     * Keys in the map need to be "numCriteria", "criterium[x].column", "criterium[x].operator" and "criterium[x].value",
     * where x is index of the criterium.
     * In addition it is possible to make a string prefix for the keys.
     * For example if prefix is "data", then keys will be "data.numCriteria", "data.criterium[x]...".
     * 
     * @param map map which contains criteria
     * @param prefix prefix which should be used
     * @param converter criterium converter
     * @return set of result criteria
     */
    public static <T> Set<Criterium<T>> map2Criteria(Map<String, String> map, String prefix,
            CriteriumStringMapConverter<T> converter) {
        if (!isCriteriaExists(map, prefix)) {
            throw new IllegalArgumentException("criteria not available: map = " + map + "; prefix = " + prefix);
        }
        if (StringUtils.isEmpty(prefix)) {
            prefix = "";
        }
        else {
            prefix = prefix + ".";
        }
        int numCriteria = Integer.valueOf(map.get(prefix + "numCriteria"));
        Set<Criterium<T>> res = new HashSet<>();
        for (int i = 0; i < numCriteria; ++i) {
            Map<String, String> critMap = new HashMap<>();
            String critPrefix = prefix + "criterium[" + i + "].";
            for (String str : map.keySet()) {
                if (str.startsWith(critPrefix)) {
                    critMap.put(str.substring(critPrefix.length()), map.get(str));
                }
            }
            res.add(converter.decode(critMap));
        }
        return res;
    }   

	
	@Override
	public int hashCode() {
		return HashCodeBuilder.reflectionHashCode(this);
	}
	
	@Override
	public boolean equals(Object obj) {
		return EqualsBuilder.reflectionEquals(this, obj);
	}
	
	@Override
	public String toString() {
		return ToStringBuilder.reflectionToString(this);
	}

}
